S04-03 集合框架-泛型
[TOC]
泛型
泛型(Generics) 是 JDK 5 引入的一个重大特性。它的本质是参数化类型(Parameterized Types),也就是说,将原本固定的具体数据类型变成一个“参数”,在使用时再传入具体的类型。
为什么需要泛型
没有泛型的痛点:
在泛型出现之前(Java 5 之前),为了让一个容器(如 ArrayList)能存储任何对象,内部只能使用 Java 的所有类之父:Object。但这带来了两个致命痛点:
类型转换繁琐:每次从容器中取数据,都必须进行强制类型转换。
运行时潜在崩溃风险:编译器无法在编译时检查类型是否匹配。如果往里面误存了不同类型的对象,代码在编译时不会报错,但一运行就会抛出
ClassCastException。
引入泛型后的对比:
没有泛型(老代码):
javaList list = new ArrayList(); list.add("Hello"); list.add(123); // 编译不报错,什么都能放 String str = (String) list.get(0); // 必须强转 String str2 = (String) list.get(1); // ❌ 运行时抛出 ClassCastException 异常!有了泛型(新代码):
javaList<String> list = new ArrayList<>(); list.add("Hello"); // list.add(123); // ❌ 编译直接报错!提前规避风险 String str = list.get(0); // 自动类型检查,无需强转
总结泛型的好处:
- 类型安全:将运行时的类型检查提前到了编译期。
- 消除强制类型转换:代码更简洁、可读性更高。
- 提高代码复用性:一套逻辑可以适用多种数据类型。
基本语法
泛型可以应用在类、接口和方法上。在语法上,通常用尖括号 < > 包裹一个或多个类型参数。
泛型类
泛型类就是在类名后面添加了类型参数。这个参数在类的内部可以作为成员变量的类型、方法的参数类型或返回值类型。
语法结构:
public class 类名<T> {
private T data;
// ...
}代码示例:
// 定义一个通用的盒子类,可以装任何类型的物品
public class Box<T> {
private T item;
public void setItem(T item) {
this.item = item;
}
public T getItem() {
return item;
}
}
// 使用泛型类
public class Main {
public static void main(String[] args) {
// 创建一个装 String 的盒子
Box<String> stringBox = new Box<>();
stringBox.setItem("魔法棒");
String item = stringBox.getItem(); // 自动就是 String 类型
// 创建一个装 Integer 的盒子
Box<Integer> intBox = new Box<>();
intBox.setItem(2026);
}
}泛型接口
泛型接口的定义与泛型类相似。实现该接口的类有两种选择:要么在实现时指定具体类型,要么让实现类也继续保持泛型。
代码示例:
// 定义泛型接口
public interface Generator<T> {
T next();
}
// 情况 A:实现类在定义时直接指定具体类型(例如 String)
public class StringGenerator implements Generator<String> {
@Override
public String next() {
return "Hello World";
}
}
// 情况 B:实现类不指定具体类型,继续延续泛型
public class GenericGenerator<T> implements Generator<T> {
private T data;
@Override
public T next() {
return data;
}
}泛型方法
泛型方法是指方法本身带有类型参数。需要特别注意的是:泛型方法是否是泛型,与它所在的类是否是泛型类无关。 也就是说,普通类里也可以定义泛型方法。
语法结构:
注意:必须在修饰符(如
public static)和返回值类型之间加上<T>。
public static <T> void printArray(T[] array) { ... }代码示例:
public class MethodDemo {
// 这是一个标准的泛型方法
public static <E> void printArray(E[] inputArray) {
for (E element : inputArray) {
System.out.printf("%s ", element);
}
System.out.println();
}
public static void main(String[] args) {
// 测试不同的数组类型
Integer[] intArray = {1, 2, 3};
String[] stringArray = {"A", "B", "C"};
// 调用泛型方法,编译器会自动推导类型
printArray(intArray); // E 被推导为 Integer
printArray(stringArray); // E 被推导为 String
}
}泛型占位符
在编写泛型代码时,你经常会看到 T, E, K, V 等字母。它们在语法上没有本质区别,换成任何字母都可以正常运行,但在业内有一套约定俗成的含义,能提高代码的可读性:
| 占位符 | 英文原意 | 常见使用场景 |
|---|---|---|
T | Type(类型) | 泛型类或普通泛型参数的最常用代称 |
E | Element(元素) | 广泛用于 Java 集合框架中(如 List<E>, Set<E>) |
K | Key(键) | 键值对映射中的“键”(如 Map<K, V>) |
V | Value(值) | 键值对映射中的“值”(如 Map<K, V>) |
N | Number(数字) | 通常用于限定只能是数值类型 |
通配符
Java 泛型是不型变(Invariant)的。简单来说,虽然 Dog 是 Animal 的子类,但 List<Dog> 并不是 List<Animal> 的子类。这就导致我们无法把 List<Dog> 传给一个接收 List<Animal> 的方法。
为了解决这种因“类型安全”而带来的灵活性限制,Java 引入了通配符(Wildcards),也就是著名的问号 ?。
Java 共有三种通配符语法:无界通配符、上限通配符和下限通配符。下面为你逐一拆解。
无界通配符:<?>
无界通配符表示“任意未知类型”。当你只关心元素的操作,而不关心元素的具体类型时,就可以使用它。
- 语法:
Class<?> - 特性: 只能读,不能写(除了
null)。
public static void printList(List<?> list) {
for (Object elem : list) {
System.out.print(elem + " "); // 1. 只能用 Object 接收
}
System.out.println();
// list.add("hello"); // ❌ 2. 编译报错!因为不知道具体类型,写入是不安全的
list.add(null); // 3. 只有 null 是特例,因为 null 是引用类型都有的值
}适用场景: 方法的实现只依赖于
Object类提供的方法(如toString()),或者只依赖于容器自身的方法(如size(),clear())。
上限通配符:<? extends T>
上限通配符用来限制类型的最高边界。它表示该类型可以是 T 本身,或者是 T 的任何子类。
- 语法:
<? extends T> - 特性(关键): 只能安全地读取(Get),不能写入(Set)。
为什么不能写:
假设有以下继承关系:Fruit -> Apple -> RedApple。
List<? extends Fruit> fruits = new ArrayList<Apple>();
// ❌ 编译报错!
// 编译器只知道 fruits 里面装的是 Fruit 的某种子类,但不知道具体是 Apple 还是 Orange。
// 如果允许你 add 一个 Orange() 进去,那原本的 ArrayList<Apple> 就被污染了!
fruits.add(new Apple());为什么能读:
因为无论底层真正实例化的是什么(Apple 还是 Orange),它们必然都是 Fruit。所以你拿出来的东西,百分之百可以用 Fruit 来接收。
public static double sumOfList(List<? extends Number> list) { // list 遍历出的元素是 Number 的某个子类
double s = 0.0;
for (Number n : list) { // ✅ 安全读取,Upcasting 向上转型
s += n.doubleValue();
}
return s;
}下限通配符:<? super T>
下限通配符用来限制类型的最低边界。它表示该类型可以是 T 本身,或者是 T 的任何父类(一路直到 Object)。
- 语法:
<? super T> - 特性(关键): 可以安全地写入(Set)
T及其子类,但读取(Get)出来的只能是Object。
为什么能写:
因为编译器知道这个容器的底线是 T。往一个“装 T 或 T 的父类”的容器里放入一个 T 对象(或 T 的子类对象),由于多态性,这绝对是安全的。
为什么不能读:
因为边界是“向上”的,最高可能是 Object。编译器无法预知你读出来的到底是 Fruit、Food 还是 Object,所以为了绝对安全,读出来的类型只能是 Object。
public static void addNumbers(List<? super Integer> list) {
list.add(1); // ✅ 1. 安全写入 Integer
list.add(2); // ✅ 2. 安全写入 Integer 的子类(如果有的话)
// Integer num = list.get(0); // ❌ 3. 编译报错!读出来的是 Object
Object obj = list.get(0); // ✅ 4. 只有这样写才行
}PECS 原则
通配符的使用经常让人头晕。对此,Java 领域有一个著名的指导原则:PECS(Producer Extends, Consumer Super)。
- Producer Extends(生产者用 extends): 如果你的数据结构主要是为了输出(生产)数据给外部使用(即只读不写),它就像一个“生产者”,此时应该使用
<? extends T>。 - Consumer Super(消费者用 super): 如果你的数据结构主要是为了接收外部输入的(消费)数据并存储(即只写不读),它就像一个“消费者”,此时应该使用
<? super T>。
经典案例:Collections.copy():
Java 源码中的 copy 方法完美阐述了这一原则:
public static <T> void copy(List<? super T> dest, List<? extends T> src) {
for (int i = 0; i < src.size(); i++) {
dest.set(i, src.get(i));
}
}src是数据源,负责提供(Produce)数据,所以用extends。dest是目标容器,负责消费(Consume)并存入数据,所以用super。
总结与对比表格
| 通配符类型 | 语法 | 含义 | 能否读取 (Get) | 能否写入 (Set) | 典型应用 (PECS) |
|---|---|---|---|---|---|
| 无界通配符 | <?> | 任意未知类型 | 只能读出 Object | 不能写(除 null) | 独立于类型的通用操作 |
| 上限通配符 | <? extends T> | T 或 T 的子类 | 能,读出为 T | 不能(除 null) | Producer(只读数据源) |
| 下限通配符 | <? super T> | T 或 T 的父类 | 只能读出 Object | 能,可写 T 及其子类 | Consumer(只写容器) |
集合与比较器中的泛型
Java 泛型最日常、最核心的战场,莫过于 Java 集合框架(Collections Framework) 和 对象比较机制(Comparable / Comparator)。在这两个领域,泛型彻底终结了过去不断强转的混乱时代。
下面我们深入探讨泛型在这两大场景下的具体应用与高级进阶(尤其是配合通配符的高级玩法)。
集合框架中的应用
Java 集合框架中的几乎所有接口和类都是泛型化的(如 List<E>, Set<E>, Map<K,V>)。这里的 E 代表 Element(元素),K 和 V 代表 Key 和 Value。
应用1:集合的声明与实例化
从 Java 7 开始,引入了钻石操作符(Diamond Operator) <>,我们在右侧的构造函数中不需要再重复写一遍类型,编译器会自动进行类型推导。
// List 集合:单列集合,允许重复
List<String> names = new ArrayList<>();
// Map 集合:双列集合(键值对)
Map<Integer, String> userMap = new HashMap<>();应用2:集合的遍历
在没有泛型前,使用 Iterator 或 for-each 遍历集合极其痛苦,因为取出来的都是 Object。有了泛型后,遍历变得优雅而安全。
List<String> list = Arrays.asList("Java", "Python", "Go");
// 显式指定类型,直接使用 String 的方法,无需强转
for (String lang : list) {
System.out.println(lang.toUpperCase());
}
比较器中的应用
Java 中实现对象排序主要依赖两个泛型接口:Comparable<T>(内部比较器)和 Comparator<T>(外部比较器)。
应用1:Comparable<T> 接口
让一个类实现 Comparable<T> 接口,意味着这个类本身具备了与其他同类对象比较的能力。
// 约束当前类只能与 Student 类型的对象进行比较
public class Student implements Comparable<Student> {
private String name;
private int age;
public Student(String name, int age) {
this.name = name;
this.age = age;
}
@Override
public int compareTo(Student other) {
// 年龄升序排序
return Integer.compare(this.age, other.age);
}
}应用2:Comparator<T> 接口
如果你无法修改某个类的源码(比如第三方库的类),或者一个类需要多种排序策略(按年龄排、按成绩排),就需要使用 Comparator<T>。
import java.util.Comparator;
// 创建一个专门按姓名排序的比较器
public class NameComparator implements Comparator<Student> {
@Override
public int compare(Student s1, Student s2) {
return s1.getName().compareTo(s2.getName());
}
}现代 Java 玩法(Lambda):在实际开发中,我们很少专门写一个比较器类,通常配合泛型使用 Lambda 表达式或方法引用:
Comparator<Student> ageComp = (s1, s2) -> Integer.compare(s1.getAge(), s2.getAge());或者是:
Comparator<Student> ageComp = Comparator.comparingInt(Student::getAge);
通配符的使用
在 Java 的 Collections 工具类中,有一个非常著名的排序方法 sort,它的源码签名定义如下:
public static <T> void sort(List<T> list, Comparator<? super T> c)这里为什么是 Comparator<? super T>,而不是 Comparator<T>?这就是我们上一篇提到的 PECS 原则 中的 Consumer Super。比较器 c 在这里是消费(使用) T 元素的,所以用 super。
为什么这样做能带来极大的灵活性:
假设有以下继承关系:Animal(父类) Dog(子类)。
由于 Dog 继承自 Animal,Dog 天然拥有 Animal 的所有属性(比如 age)。现在我们有一个通用的动物年龄比较器:
// 一个可以比较任何动物年龄的比较器
Comparator<Animal> animalAgeComparator = (a1, a2) -> Integer.compare(a1.getAge(), a2.getAge());如果我们想对一个装满小狗的集合进行排序:
List<Dog> dogs = new ArrayList<>();
dogs.add(new Dog("哈士奇", 3));
dogs.add(new Dog("柯基", 1));
// 这里的 T 是 Dog。
// Comparator<? super Dog> 意味着可以接受 Comparator<Dog>,也可以接受 Comparator<Animal>。
Collections.sort(dogs, animalAgeComparator);- 如果签名是
Comparator<T>:那么Collections.sort(dogs, ...)就只能接收Comparator<Dog>,上面的animalAgeComparator就会编译报错。这就逼着你为Dog重新写一个一模一样的比较器,造成代码冗余。 - 因为签名是
Comparator<? super T>:父类的比较器可以直接复用到子类集合的排序中。这完美体现了面向对象的多态性与泛型结合的威力。
实战:泛型的应用
下面的代码完整展示了泛型集合、Lambda 比较器以及利用 Comparator 进行多级排序的优雅写法。
import java.util.*;
public class CollectionGenericDemo {
public static void main(String[] args) {
// 1. 声明泛型集合
List<Student> students = new ArrayList<>();
students.add(new Student("Tom", 22));
students.add(new Student("Jerry", 20));
students.add(new Student("Alice", 20));
// 2. 使用具有泛型推导的 Comparator 进行复合排序:先按年龄排,年龄相同按姓名排
students.sort(
Comparator.comparingInt(Student::getAge)
.thenComparing(Student::getName)
);
// 3. 打印结果
students.forEach(s -> System.out.println(s.getName() + ": " + s.getAge()));
}
}集合与比较器是 Java 泛型最成功的实践。掌握了 ? extends T(只读数据源)和 ? super T(只写/消费容器,如 Comparator)的逻辑,你在阅读 Spring、MyBatis 或是 JDK 源码中复杂的集合操作时,就再也不会感到吃力了。
继承中的泛型
在 Java 中,将泛型与面向对象的继承(Inheritance)结合起来时,极易产生直觉上的误区。理解泛型在继承中的行为,是编写健壮框架和容器类代码的关键。
下面为你详细梳理 Java 泛型在继承中的四大核心规则、子类继承父类的常见姿势,以及如何利用通配符恢复继承关系。
泛型不型变
List<Dog> 不是 List<Animal> 的子类:
这是初学者最容易踩的坑。在 Java 中,如果 Dog 是 Animal 的子类,我们直觉上会认为 List<Dog> 也是 List<Animal> 的子类。但事实完全相反:它们之间没有任何继承关系。
为什么?
假设 Java 允许这种继承关系,那么以下代码将合法,但会导致严重的运行时崩溃:
List<Dog> dogs = new ArrayList<>();
List<Animal> animals = dogs; // 1. 假设这行能编译通过
// 2. animals 和 dogs 指向同一个内存地址
animals.add(new Cat()); // ❌ 完蛋了!把一只猫放进了狗窝里
Dog dog = dogs.get(0); // ❌ 运行时抛出 ClassCastException,因为拿到的是猫!为了在编译期杜绝这种安全隐患,Java 规定:无论两个泛型参数之间有什么继承关系,它们的泛型组合(如 List<A> 和 List<B>)在编译期都是平辈关系,互不兼容。
真正的泛型继承轴线
虽然 List<String> 和 List<Object> 没有继承关系,但泛型类/接口本身的继承链依然有效,前提是泛型参数必须保持一致。
正确的继承关系示例:
ArrayList<String>是List<String>的子类。List<String>是Collection<String>的子类。HashSet<Integer>是Set<Integer>的子类。java// 正确:类型参数一致,容器类本身存在继承关系 List<String> list = new ArrayList<String>(); Collection<Integer> col = new HashSet<>(); // 错误:虽然 Object 是 String 的父类,但整体不构成继承 // ArrayList<Object> list2 = new ArrayList<String>();
子类继承泛型父类方式
当你自己定义一个类,去继承一个泛型父类(或实现泛型接口)时,Java 提供了三种处理泛型参数的方式。我们以父类 Father<T> 为例:
public class Father<T> {
private T info;
public void setInfo(T info) { this.info = info; }
public T getInfo() { return info; }
}方式1:擦除父类泛型
如果不给父类传任何类型,父类的 T 会被擦除为 Object。这种做法失去了泛型的意义,属于老旧代码的写法。
// 子类变成普通类,父类中的 T 变成 Object
public class ChildA extends Father {
// 此时重写的方法变成了:
// @Override public void setInfo(Object info) { ... }
}方式2:子类定死父类泛型
在继承时,直接给父类传入一个具体的类型。此时,子类变成了普通类。
// 子类明确了父类处理的是 String
public class ChildB extends Father<String> {
// 此时重写的方法自动变为具体类型:
@Override
public void setInfo(String info) { super.setInfo(info); }
}
// 使用时:
ChildB cb = new ChildB();
cb.setInfo("具体字符串"); // 只能传 String方式3:子类延续泛型
子类自己也定义成泛型类,并将自己的泛型参数(或其中之一)传递给父类。
// 子类也是泛型类,把自己的 T 传给父类
public class ChildC<T> extends Father<T> {
// 依然保持泛型特性
}// 甚至子类可以扩展更多的泛型参数
public class ChildD<T, E> extends Father<T> {
private E extraData; // 子类独有的新泛型参数
}通配符恢复继承
如果你确实面临一种业务场景:需要写一个方法,既能接收 List<Dog>,又能接收 List<Animal>。既然直接的继承行不通,这时候就轮到~~通配符(Wildcards)~~出场了。
我们可以通过上限/下限通配符,在泛型之间架起一座“伪继承”的桥梁:
extends 向上继承
// List<? extends Animal> 成了 List<Dog> 和 List<Animal> 的共同父类!
List<? extends Animal> list1 = new ArrayList<Dog>();
List<? extends Animal> list2 = new ArrayList<Animal>();原理:
? extends Animal表示“某种继承自 Animal 的未知子类”。因为Dog满足这个条件,所以List<Dog>可以安全地向上赋值给它。
super 向下继承
// List<? super Dog> 成了 List<Dog> 和 List<Animal> 的共同父类!
List<? super Dog> list3 = new ArrayList<Dog>();
List<? super Dog> list4 = new ArrayList<Animal>();原理:
? super Dog表示“某种是 Dog 父类的未知类型”。因为Animal满足这个条件,所以List<Animal>也可以安全地赋值给它。
泛型继承判断口诀
在开发中,当你需要判断两个泛型表达式是否具有继承/赋值关系时,请按以下顺序检查:
看外层容器(类/接口):如果外层容器没有继承关系(例如
List和Set),那它们绝对不能互相赋值。看括号内的类型:
- 如果都是确切类型(如
<String>与<Object>),必须完全一致才能赋值。 - 如果包含通配符(如
<? extends Animal>),则根据通配符的上限或下限,判断右侧的类型是否落在左侧的“射程范围”内。
- 如果都是确切类型(如
类型擦除
类型擦除(Type Erasure)是 Java 泛型实现的核心机制。简单来说:Java 的泛型只存在于编译期,在进入运行期(JVM)之后,所有的泛型信息都会被消灭。
这就是为什么人们常说 Java 的泛型是“伪泛型”。下面为你彻底拆解类型擦除的工作原理、背后的设计初衷以及它带来的副作用。
工作流程
在编译期间,Java 编译器(javac)会检查你的泛型代码是否安全,一旦检查通过,它就会进行“擦除”操作。主要做三件事:
步骤1:替换类型参数
编译器会把所有的泛型参数(如 T, E)替换为它们的原始类型(Raw Type)。
- 如果泛型没有指定上限(如
<T>),则替换为Object。 - 如果指定了上限(如
<T extends Number>),则替换为上限类型Number。
编译前(你写的代码):
public class Holder<T> {
private T value;
public T getValue() { return value; }
public void setValue(T value) { this.value = value; }
}编译后(JVM 实际看到的字节码):
public class Holder {
private Object value; // T 被擦除为 Object
public Object getValue() { return value; }
public void setValue(Object value) { this.value = value; }
}步骤2:自动插入强制类型转换
既然 JVM 内部全是 Object,那为什么我们写代码时不需要强转呢?因为编译器在调用泛型方法的地方,自动帮我们加上了强转代码。
编译前:
Holder<String> holder = new Holder<>();
holder.setValue("Hello");
String str = holder.getValue(); // 无需强转编译后:
Holder holder = new Holder();
holder.setValue("Hello");
String str = (String) holder.getValue(); // 编译器自动插入了 (String) 强转步骤3:生成桥接方法
为了保证泛型在继承或实现接口时的多态性,编译器有时不得不秘密生成一个“桥接方法”。
假设有一个泛型接口和它的实现类:
public interface Node<T> {
void setData(T data);
}
public class MyNode implements Node<Integer> {
@Override
public void setData(Integer data) { ... }
}类型擦除后,Node 接口的方法变成了 setData(Object data)。如果 MyNode 只有 setData(Integer),那就无法实现接口的动态绑定了。
于是,编译器会偷偷在 MyNode 中生成一个桥接方法:
// 编译器自动生成的桥接方法
public void setData(Object data) {
this.setData((Integer) data); // 调用你写的具体方法
}类型擦除采用原因
Java 在 2004 年(Java 5)引入泛型时,面临一个巨大的历史包袱:如何让新写的泛型代码和过去 10 年写的老代码(无泛型)无缝兼容?
C++ 采用的方法是“模板实现”(为每种类型复制一份新代码),这会导致“代码膨胀”,且新老代码无法兼容。
Java 最终选择了二进制兼容性(Binary Compatibility)。通过类型擦除,Java 5 的泛型集合类(如 ArrayList<T>)编译后的字节码,和 Java 1.4 的老集合类(如 ArrayList)几乎一模一样。老系统的 .class 文件不需要重新编译,就能直接在新的 JVM 上运行。
类型擦除验证方法
如果你想在代码中亲自验证类型擦除,可以用以下两个经典方法:
实验1:运行时类检查
Class c1 = new ArrayList<String>().getClass();
Class c2 = new ArrayList<Integer>().getClass();
System.out.println(c1 == c2); // 输出:true结论:在运行时,
ArrayList<String>和ArrayList<Integer>的二进制类对象是完全同一个,泛型标签消失了。
实验2:利用反射绕过泛型检查
因为泛型约束只在编译期有效,运行期被擦除,所以我们可以通过反射往一个 List<String> 里面塞进一个整数:
List<String> list = new ArrayList<>();
list.add("Java");
// 利用反射获取运行时的 add 方法(此时参数已经是 Object 了)
Method method = list.getClass().getMethod("add", Object.class);
method.invoke(list, 2026); // 成功塞入一个 Integer!
System.out.println(list); // 输出:[Java, 2026]类型擦除副作用
天下没有免费的午餐,为了兼容性而选择的类型擦除,给 Java 留下了诸多的限制(也就是大名鼎鼎的“泛型坑”):
无法使用基本数据类型:不能写
List<int>。因为擦除后会变成Object,而int不是对象。必须使用包装类List<Integer>。无法使用
instanceof关键字:java// 编译报错!JVM 在运行时根本不知道什么是 List<String> if (obj instanceof List<String>) { }无法直接实例化泛型:
java// 编译报错!因为擦除后变成了 new Object(),失去了原本的设计意图 T item = new T();泛型数组不合法:
java// 编译报错!Java 禁止创建确切的泛型类型数组 List<String>[] lists = new ArrayList<String>[10];方法重载冲突:下面这两个方法在编译后方法签名一模一样(都是
List),因此编译器拒绝编译:javapublic void print(List<String> list) {} public void print(List<Integer> list) {} // 编译报错:Method signature conflicts
练习题
习题1:泛型操作类 DAO

习题2:泛型类 User



习题3:泛型方法

习题4:泛型方法

习题5:泛型类 Student

